GitHub OAuth 重定向授权流(标准登录)

概述

本模块实现了 GitHub OAuth2 Authorization Code Flow(授权码模式),即点击”GitHub”按钮 → 跳转 GitHub 授权 → 回调登录的标准三方登录方式。

与 Device Flow(扫码登录)的区别:

特性 标准重定向流 Device Flow(扫码)
用户操作 点按钮 → 跳 GitHub → 授权 → 跳回 扫二维码 → 手机确认
回调方式 GitHub 直接回调后端 redirect_uri 前端轮询后端,后端轮询 GitHub
适用场景 PC 浏览器 大屏/无法跳转的环境
复杂度 低(标准 OAuth) 中(需要轮询)

完整流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
┌─────────────────┐         ┌─────────────────┐         ┌─────────────────┐
│ 前端 (浏览器) │ │ 后端 (8081) │ │ GitHub API │
│ localhost:8082 │ │ │ │ │
└────────┬────────┘ └────────┬─────────┘ └────────┬────────┘
│ │ │
│ ① 点击 "GitHub" 按钮 │ │
│ 跳转到: │ │
│ /shop/oauth/github │ │
├──────────302─────────────► │ │
│ │ │
│ │ ② 构造授权 URL │
│ │ (含 client_id, scope, │
│ │ redirect_uri, state) │
│ │ │
│ ③ 302 重定向 │ │
│◄────── 302 ───────────────┤ │
│ │ │
│ ④ 浏览器跳转到 GitHub │ │
│ https://github.com/ │ │
│ login/oauth/authorize │ │
├─────────────────────────────────────────────────────► │
│ │ │
│ ⑤ 用户在 GitHub 上 │ │
│ 确认授权 │ │
│ │ │
│ ⑥ GitHub 回调后端 │ │
│ GET /shop/oauth/callback │ │
│ /github?code=xxx&state=yyy│ │
├──────────────────────────► │ │
│ │ │
│ │ ⑦ 用 code 换 access_token│
│ │ POST /login/oauth/ │
│ │ access_token │
│ │ ───────────────────────► │
│ │ ◄── 返回 access_token │
│ │ │
│ │ ⑧ 获取 GitHub 用户信息 │
│ │ GET /user
│ │ ───────────────────────► │
│ │ ◄── 返回 GitHub 用户 │
│ │ │
│ │ ⑨ 查 / 创建 ShopUser │
│ │ 生成 JWT token │
│ │ 生成 refresh token │
│ │ 用户信息 → JSON → Base64 │
│ │ │
│ ⑩ 重定向到前端 │ │
│ /#/shop/oauth/callback │ │
│ ?token=xxx&refreshToken= │ │
│ yyy&user=base64... │ │
│◄────── 302 ───────────────┤ │
│ │ │
│ ⑪ 前端口令页面: │ │
│ 解析 URL query: │ │
│ - 保存 token / refresh │ │
│ - 解码 user Base64 → JSON │ │
│ - 存 localStorage │ │
│ - 跳转 /shop │ │
│ │ │

后端实现

1. OAuthService 接口

文件: src/main/java/com/.../service/OAuthService.java

1
2
3
4
5
// 构造 GitHub 授权 URL,返回重定向地址
String authorize(String provider);

// 处理 GitHub 回调:换 token → 取用户 → 建/查用户 → 生成 JWT → 重定向到前端
void callback(String provider, String code, String state, HttpServletResponse response) throws IOException;

2. OAuthServiceImpl 实现

文件: src/main/java/com/.../service/impl/OAuthServiceImpl.java

常量

1
2
3
4
5
6
private static final String GITHUB_AUTHORIZE_URL = "https://github.com/login/oauth/authorize";
private static final String GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token";
private static final String GITHUB_USER_URL = "https://api.github.com/user";
private static final String GITHUB_EMAIL_URL = "https://api.github.com/user/emails";
private static final String REFRESH_KEY_PREFIX = "weblog:refresh:";
private static final long REFRESH_TOKEN_TTL_SECONDS = 7 * 24 * 3600; // 7 天

authorize()

流程: 构造 GitHub OAuth 授权 URL 并返回

1
2
3
4
5
6
7
8
public String authorize(String provider) {
String state = UUID.randomUUID().toString().replace("-", "");
return GITHUB_AUTHORIZE_URL
+ "?client_id=" + oauthProperties.getClientId()
+ "&redirect_uri=" + URLEncoder.encode(oauthProperties.getRedirectUri(), StandardCharsets.UTF_8)
+ "&scope=read:user,user:email"
+ "&state=" + state;
}

生成的 URL 示例:

1
2
3
4
5
https://github.com/login/oauth/authorize
?client_id=xxx
&redirect_uri=http://localhost:8081/shop/oauth/callback/github
&scope=read:user,user:email
&state=3f7a2b1c...

callback()

完整步骤:

1
2
3
4
5
6
7
8
9
10
1. 验证 provider = "github"
2. 调用 getGitHubAccessToken(code) → 换取 access_token
3. 用 access_token 调 GET /user → 获取 GitHub 用户信息
4. 用 access_token 调 GET /user/emails → 获取主邮箱(备用)
5. 查数据库 shop_user 表:WHERE oauth_provider='github' AND oauth_id=github_id
6. 如果不存在 → 创建新用户(用 UUID 作为 uuid
7. 如果存在 → 更新昵称/头像
8. 生成 JWT token(subject = uuid)+ refresh token(存 Redis)
9. 将用户信息序列化 JSON → Base64(URL-safe 编码)
10. 302 重定向到前端:/#/shop/oauth/callback?token=xxx&refreshToken=yyy&user=base64

错误处理: 任何步骤失败 → 重定向到 /#/shop/auth?oauth_error=错误信息

getGitHubAccessToken()

文件: 同文件 380-410 行

1
2
3
4
5
6
7
8
POST https://github.com/login/oauth/access_token
Content-Type: application/x-www-form-urlencoded
Accept: application/json

client_id=xxx
&client_secret=xxx
&code=xxx
&redirect_uri=http://localhost:8081/shop/oauth/callback/github

Response:

1
2
3
4
5
{
"access_token": "gho_xxx",
"token_type": "bearer",
"scope": "read:user,user:email"
}

重试机制: 内置 3 次重试,指数退避(1s → 2s → 4s),应对国内网络访问 GitHub 不稳定的情况。

getGitHubUser()

1
2
3
GET https://api.github.com/user
Authorization: Bearer gho_xxx
Accept: application/json

Response(关键字段):

1
2
3
4
5
6
7
{
"id": 12345678,
"login": "octocat",
"name": "monalisa octocat",
"avatar_url": "https://avatars.githubusercontent.com/u/12345678?v=4",
"email": "octocat@github.com"
}

getGitHubPrimaryEmail()

/user 返回的 email 为 null 时,调用 /user/emails 获取已验证的主邮箱:

1
2
3
GET https://api.github.com/user/emails
Authorization: Bearer gho_xxx
Accept: application/json

Response:

1
2
3
4
[
{"email": "octocat@github.com", "primary": true, "verified": true, "visibility": "public"},
{"email": "octocat@users.noreply.github.com", "primary": false, "verified": true, "visibility": null}
]

buildUserBase64()

将用户 ID、昵称、头像序列化为 JSON → URL-safe Base64:

1
2
String json = objectMapper.writeValueAsString(userMap);
return Base64.getUrlEncoder().encodeToString(json.getBytes(StandardCharsets.UTF_8));

注意: Base64.getUrlEncoder() 使用 -_ 替代 +/,前端 atob() 不认识这种变体,需手动替换。

3. OAuthController

文件: src/main/java/com/.../controller/OAuthController.java

端点 方法 说明
/shop/oauth/{provider} GET 跳转到 OAuth 提供商授权页(302)
/shop/oauth/callback/{provider} GET OAuth 回调处理

authorize 端点

1
2
3
4
5
@GetMapping("/{provider}")
public void authorize(@PathVariable String provider, HttpServletResponse response) throws IOException {
String url = oAuthService.authorize(provider);
response.sendRedirect(url);
}

访问 http://localhost:8082/shop/oauth/github → Vite 代理到 http://localhost:8081/shop/oauth/github → 302 到 GitHub 授权页

callback 端点

1
2
3
4
5
6
7
@GetMapping("/callback/{provider}")
public void callback(@PathVariable String provider,
@RequestParam String code,
@RequestParam(required = false) String state,
HttpServletResponse response) throws IOException {
oAuthService.callback(provider, code, state, response);
}

GitHub 授权后回调到 http://localhost:8081/shop/oauth/callback/github?code=xxx&state=yyy

4. 用户模型

: shop_user

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
CREATE TABLE `shop_user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`uuid` varchar(64) DEFAULT NULL COMMENT '用户唯一标识(JWT subject)',
`nickname` varchar(64) DEFAULT NULL COMMENT '昵称',
`avatar` varchar(512) DEFAULT NULL COMMENT '头像',
`phone` varchar(20) DEFAULT NULL COMMENT '手机号',
`password` varchar(256) DEFAULT NULL COMMENT '密码',
`oauth_provider` varchar(20) DEFAULT NULL COMMENT 'OAuth 提供商',
`oauth_id` varchar(128) DEFAULT NULL COMMENT 'OAuth 平台用户 ID',
`status` int(11) DEFAULT '1',
`register_time` datetime DEFAULT NULL,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_phone` (`phone`),
UNIQUE KEY `uk_oauth` (`oauth_provider`,`oauth_id`),
UNIQUE KEY `uk_uuid` (`uuid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

OAuth 用户的 phone/password 为空,通过 oauth_provider + oauth_id 唯一标识。

UUID 作为 JWT 的 subject,不暴露 oauth_id 等外部平台 ID。

5. JWT Token 体系

生成

1
String token = jwtTokenHelper.generateToken(uuid, "shop", accessTokenExpireTime);
  • subject: shop_user.uuid(随机 UUID,不暴露业务 ID)
  • type: shop(区分 admin 和 shop 用户)
  • expire: 由 jwt.accessTokenExpireTime 配置

Refresh Token

1
2
3
String refreshToken = UUID.randomUUID().toString().replace("-", "");
String refreshKey = "weblog:refresh:" + refreshToken;
redisTemplate.opsForValue().set(refreshKey, uuid, 7, TimeUnit.DAYS);
  • refresh token 存 Redis,7 天过期
  • subject 存为 Redis value,用于刷新时验证
  • 刷新端点在:POST /shop/auth/refresh

6. 安全配置

文件: WebSecurityConfig.java

1
2
.antMatchers("/shop/oauth/**").permitAll()
.antMatchers("/shop/auth/**").permitAll()

OAuth 端点需要匿名访问,因为用户未登录时才能点击 GitHub 授权。

7. SSL 握手失败重试

请求 GitHub Token 端点时,RestTemplate 配置了 10s 连接超时、30s 读取超时,并添加重试:

1
2
3
// getGitHubAccessToken() 中
int maxRetries = 3;
int retryDelayMs = 1000; // 指数退避: 1s → 2s → 4s

并通过系统属性强制 TLSv1.2 避免 TLS 1.3 在某些网络环境被中断:

1
System.setProperty("https.protocols", "TLSv1.2");

前端实现

1. 登录页

文件: weblog-vue3/src/pages/frontend/shop-login.vue

GitHub 按钮

1
2
3
4
<a :href="githubAuthUrl" class="...">
<svg class="w-5 h-5 mr-1"><!-- GitHub Icon --></svg>
GitHub
</a>
1
const githubAuthUrl = `${window.location.origin}/shop/oauth/github`
  • 直接使用 <a> 标签跳转(需要有 redirect_uri 白名单,不能用前端路由)
  • URL 是 http://localhost:8082/shop/oauth/github → Vite 开发服务器代理到后端

OAuth 错误显示

1
2
3
4
5
6
onMounted(() => {
const oauthError = route.query.oauth_error
if (oauthError) {
ElMessage.error('GitHub 登录失败: ' + decodeURIComponent(oauthError))
}
})

后端 callback 失败时会 302 到 /#/shop/auth?oauth_error=xxx,前端解析并弹窗显示。

自动登录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
onMounted(() => {
tryAutoLogin()
})

async function tryAutoLogin() {
const token = getShopToken()
const refreshToken = getShopRefreshToken()
if (!token && !refreshToken) return

const payload = parseJwt(token)
const now = Math.floor(Date.now() / 1000)

// access token 未过期 → 直接跳转
if (payload && payload.exp > now) {
router.replace(redirect)
return
}

// 尝试用 refresh token 续期
if (refreshToken) {
const res = await axios.post('/shop/auth/refresh', { refreshToken })
if (res.data?.data) {
setShopTokens(res.data.data, refreshToken)
router.replace(redirect)
}
}
}

2. 回调页

文件: weblog-vue3/src/pages/frontend/shop-oauth-callback.vue

GitHub 授权完成后,后端 302 到该页面(hash 路由参数形式):

1
http://localhost:8082/#/shop/oauth/callback?token=xxx&refreshToken=yyy&user=base64...

页面逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
onMounted(() => {
const token = route.query.token
const refreshToken = route.query.refreshToken
const userBase64 = route.query.user

// 1. 验证 token 存在
if (!token || !refreshToken) {
ElMessage.error('登录失败:缺少认证信息')
router.replace('/shop/auth')
return
}

// 2. 保存 token
setShopTokens(token, refreshToken)

// 3. 解析用户信息(Base64 → JSON)
if (userBase64) {
const standardBase64 = userBase64.replace(/-/g, '+').replace(/_/g, '/')
const userStr = atob(standardBase64)
const user = JSON.parse(userStr)
localStorage.setItem('shop_user', JSON.stringify(user))
}

// 4. 跳转到商城首页
ElMessage.success('登录成功')
router.replace('/shop')
})

3. Token 管理

文件: weblog-vue3/src/composables/shopAuth.js

1
2
3
4
5
6
7
// 存
setShopTokens(token, refreshToken)
// -> localStorage: shop_token, shop_refresh_token

// 读
getShopToken() // -> shop_token
getShopRefreshToken() // -> shop_refresh_token

4. Vite 代理配置

1
2
3
4
5
6
7
// vite.config.js
proxy: {
'/shop/': {
target: 'http://localhost:8081',
changeOrigin: true
}
}

数据流详解

用户信息流

1
2
3
4
5
6
7
8
GitHub API                 后端                            前端
┌────────┐ ┌────────┐ ┌────────┐
│ id │ ──→ oauthId │ │ │ │
│ login │ ──→ nickname │ JSON │ → Base64 → │ JSON
│ name │ ──→ nickname │ → │ │ → │
│ avatar │ ──→ avatar │ Base64 │ │ 存储 │
│ email │ ──→ email │ │ │ │
└────────┘ └────────┘ └────────┘

Token 流

1
2
3
4
5
6
7
8
9
10
后端                                          前端
┌─────────────────────────────────┐ ┌────────────┐
JWT (subject=uuid, type=shop) │ ──→ │ localStorage │
│ Refresh Token (Redis, 7天) │ ──→ │ shop_token
│ │ │ shop_refresh│
│ 每次请求携带 JWT (Authorization │ └────────────┘
Bearer header) │
│ → TokenAuthenticationFilter │
│ 验证 JWT 签名 + 过期 + 撤销检查 │
└─────────────────────────────────┘

错误异常流

1
2
3
4
流程中任何异常 → catch(Exception) → log.error → 302 到
/#/shop/auth?oauth_error=URL编码的错误信息

前端解析 route.query.oauth_error → ElMessage.error 弹窗

配置项

application.yml

1
2
3
4
5
6
7
8
9
oauth:
github:
client-id: ${GITHUB_CLIENT_ID}
client-secret: ${GITHUB_CLIENT_SECRET}
redirect-uri: http://localhost:8081/shop/oauth/callback/github
frontend-url: http://localhost:8082

jwt:
accessTokenExpireTime: 7200000
配置 说明
oauth.github.client-id GitHub OAuth App 的 Client ID
oauth.github.client-secret GitHub OAuth App 的 Client Secret(敏感!)
oauth.github.redirect-uri GitHub 回调地址,需在 GitHub App 设置中添加白名单
oauth.github.frontend-url 前端地址,用于回调成功后的重定向
jwt.accessTokenExpireTime JWT 过期时间(毫秒),默认 2 小时

回调地址白名单

在 GitHub OAuth App 设置页面,需将 redirect_uri 加入 Authorization callback URL

1
http://localhost:8081/shop/oauth/callback/github

踩坑记录

1. Vite 代理路径匹配

问题: 开发环境前端 8082,后端 8081,直接请求 /shop/oauth/github 需要代理

解决: Vite 配置 proxy: { '/shop/': { target: 'http://localhost:8081' } },前端 githubAuthUrl = window.location.origin + '/shop/oauth/github' → 浏览器请求 localhost:8082/shop/oauth/github → Vite 代理到 localhost:8081/shop/oauth/github

2. Hash 路由回调处理

问题: 后端回调重定向到 /#/shop/oauth/callback?token=xxx,但 Vue Router 是 hash 模式

解决: redirect URL 拼接为 frontendUrl + "/#/shop/oauth/callback?token=" + token。注意 # 前不能有 ?,否则 hash 会被当作 query parameter 的一部分。

3. Base64 URL 安全编码兼容

现象: 前端 atob() 解码用户信息时报错 Invalid character

原因: 后端 Base64.getUrlEncoder() 使用 URL-safe 字符集(-_),前端 atob() 只认标准 Base64(+/

解决:

1
2
const standardBase64 = userBase64.replace(/-/g, '+').replace(/_/g, '/')
const userStr = atob(standardBase64)

4. SSL 握手失败(国内网络)

现象: RestTemplate 调用 GitHub API 时报错 Remote host terminated the handshake

原因: 国内访问 GitHub 偶发 TLS 连接中断;部分代理/VPN 对 TLS 1.3 兼容性不好

解决:

  • 强制 TLSv1.2: System.setProperty("https.protocols", "TLSv1.2")
  • 重试机制:getGitHubAccessToken() 最多重试 3 次

5. OAuth 用户与手机号用户共存

注意: OAuth 注册的用户 phone / password 为空,登录时不能走手机号密码校验

实现:

  • shop_user 表的 phone / password 字段设可为空
  • 唯一索引 uk_phone 只约束非空的 phone
  • JWT 过滤器通过 uuid(非 phone)查找用户,兼容两种登录方式
  • Token 签发时 type=shop,与 admin 用户隔离

6. Session 与 token 的关系

注意: OAuth 登录后后端做 response.sendRedirect() 到前端并携带 token query,这是「URL 传参」,不需要 session。前端保存 token 到 localStorage 后,后续请求通过 Authorization: Bearer xxx 头携带。


测试指南

本地测试

  1. 确保 Vite 前端(8082)和后端(8081)都在运行
  2. 访问 http://localhost:8082/#/shop/auth
  3. 如果已登录,先清除 localStorage 中的 shop_tokenshop_refresh_token
  4. 点击 “GitHub” 按钮
  5. 浏览器跳转到 GitHub 授权页,登录 GitHub 账号并授权
  6. GitHub 回调后端 → 后端处理 → 重定向到前端回调页
  7. 浏览器自动跳转到商城首页,右上角显示用户头像和昵称

直接 API 测试

1
2
3
4
5
6
7
# 1. 获取授权地址(后端返回 302,用 -v 看 Location header)
curl -v http://localhost:8081/shop/oauth/github 2>&1 | grep -i "location"

# 2. 手动复制 location URL 到浏览器打开,完成 GitHub 授权
# GitHub 会回调 http://localhost:8081/shop/oauth/callback/github?code=xxx

# 3. 查看后端日志确认登录成功

GitHub OAuth 重定向授权流(标准登录)
https://neoisconstantine-github-io.pages.dev/2025/06/18/GitHub OAuth 重定向授权流(标准登录)/
作者
constantine
发布于
2025年6月18日
许可协议